|     java gc jvm memory model   |    

垃圾回收

推荐一篇文章,《Java垃圾回收机制》(简书作者:可文分身),深入浅出。再结合比如这篇IBM的《Java内存详解》,以及Oracle的Java使用手册,对Java的垃圾回收机制先有了一个初步的了解。感谢原作者可文分身以及Andrew Hall

JVM的内存结构

javaMemory

首先从机器层面讲,如上图所示操作系统和C-运行时库肯定本身就占一部分内存。如果我们把系统分配给Java进程的所有内存统称为Java Runtime Area的话,系统可以笼统地分成三块,1)系统区(灰色) 2)Java区(蓝色的逻辑堆和绿色的JVM) 3)剩下的本机堆(淡绿色)。上图中吧Java Runtime Area分成蓝色的逻辑堆和绿色的JVM区实际上并不准确。那么一个Java进程到底包含哪些信息,占用哪些内存空间呢?我们先看下面这这两张图: javaModel jvm3 根据上图,一个Java程序所占用的空间主要可以分成三大块。如果搞清楚了一个编译过的代码到底是怎么被执行的,那么内存的分配其实很好理解:

  1. 逻辑堆:首先最大也是最重要的一块就叫逻辑堆。这个堆对应的就是第一张图中的Java Heaps区。主要是用来存放对象实例数组,所以也可以叫对象堆。由所有线程共享。Java号称一切都是对象,一个Java程序产生的所有对象都存在这儿。大家都知道Heap堆区是动态分配内存的,空间大小和生命周期都不明确,Java垃圾回收器的主要作用就是自动释放逻辑堆里实例对象所占的内存。所以呢今晚我们的主角就是它!其内部为了清理更有效率,主要还分成年轻代老年代两个不同区。年轻代内部还分成EdenSurvivorFrom以及SurvivorTo三部分。后面我们会详细介绍。
  2. 方法区(原PermGene永生代):方法区主要储存由类加载器ClassLoader加载的类信息。概念上类似于常规编程语言的已编译代码的储存区。用于储存包括类的元数据常量池普通字段静态变量,方法内的局部变量以及编译好的字节码。方法区同样也是由所有线程共享。严格讲,方法区不属于逻辑堆,属于非堆内存。从Java8开始,永生代这个本来就比较别扭的说法被取消,原先永生代中大部分内容比如类的元信息会被放入本地内存(元数据区,Metaspace),而类的静态变量和内部字符串被分离出放入到逻辑堆中。所以呢,现在Java8的逻辑堆实际是包含类的静态变量,和局部字符串池的。
  3. 本机区(主要是Stack):剩下内容都存放在本机堆,而且都是单线程私有的。其中最主要的就是JVM Stack(虚拟机堆栈区)。每个对象被创建的时候,在堆栈区都有一个对它的引用。这个引用就都存在JVM Stack上。另外八种基本型也都直接存在堆栈区,因为他们的空间大小和生命周期明确。除了JVM Stack,本机区还包括一个”Native Method Stack(本机堆栈,也称C Stack)“,虚拟机中的JIT即时编译单元负责本机系统中比如C语言或者C++语言和Java程序间的互相调用,这个Native Method Stack就是用来存放与本线程互相调用的本机代码。除了以上两种堆栈,本机区还包括一个”PC Register(寄存器)“。Java支持多线程,系统需要给每个线程单独分配一个本机进程编号,这就要用到寄存器。

下面这张图图就是对Java Runtime Data的各部分分区的很好的总结,基本一一对应了我上面提到的重要的点。Oracle的Java使用手册 第二章-虚拟机的结构里对图中的每一部分都有准确的描述,不明白可以去查。 jvm2

逻辑堆(对象堆)的结构

现在我们把灯光对准今天的主角。如下图所示,逻辑堆分成“年轻代”“老年代”两部分。图中的永生代请无视,理由上面解释过了。而年轻代又分为两种,一种是“Eden”区域(伊甸园,名字好美),另外一种是两个大小对等的Survivor区域:from区to区。这名字其实很形象,因为一个新实例化的对象,它的内存分配都在年轻代,具体地说是在年轻代的Eden区,小孩都在伊甸园光着屁股跑。而老年代的实例年龄就要大很多,而且比年轻代的实例更稳定。之所以将Java内存按照分代进行组织,主要是基于这样一个事实:大多数对象都在年轻时候死亡。所以年轻代相对老年代需要更频繁的清理。把他们区分开来,配用不同的清理策略,有助于提高效率。 generation

年轻代垃圾回收

在年轻代上,Java的垃圾回收使用的是Mark-Copy算法。顾名思义,算法分成MarkCopy两个步骤。Mark指的是标记出所有还活着的实例,然后清扫掉所有未被标记的实例,空出内存,实际这个过程叫做Mark-Sweep算法(详见标记-清扫算法这篇文章)。然后Copy部分就是将幸存的不同年龄的实例拷贝到别的分代。下面我们就对这两个过程一一讲解。

标记存活实例

讲到垃圾回收,我们的第一反应一定是怎么标记垃圾。比如最简单的区分技术:引用计数(reference conunting)。每个对象都含有一个引用计数器。当有引用指向对象时,计数器加一。引用脱钩,计数器减一。但这个方法有个缺陷,想想看两个对象互相引用,但实际上他们已经脱离全世界的情况,他们各自的计数器都不是零。所以这种方法几乎很少被使用。

Java垃圾回收使用的策略恰好相反,是标记所有存活的实例,其他的全部清除。考虑到”大多数对象都在年轻时死亡”这个事实,搜索活着的比搜索死去的更省事儿。从下图中,我们可以看到Java是从所谓的“根对象”开始地毯式扫描,遍历所有和根对象有直接或间接引用关系的实例。 gc-roots

那关键问题是,哪些对象是“根对象”呢?根据How Garbage Really Works这篇文章,根对象主要包括四类对象:

  1. stack栈中引用的对象:这主要指main方法中产生的储存在JVM栈中的对对象的引用。以前已经分析过,java对象都存在heap区,对对象的引用都存在stack区。GC会去找当前stack区里还留有的main方法产生的引用。这是我们实例最主要的来源。下面这张图非常地形象。因为栈里的引用的生命周期都和他在代码里的作用域挂钩,比如说出了括号,括号里声明的引用就会从stack里擦除,跟着的一大串对象就再也不可能被标记到了。 StackVsHeap
  2. static静态变量:静态方法和变量不产生实例,直接由类引用。Java的类由java.lang.ClassLoader类加载器加载,类的数据都不在逻辑堆,而是存在永生代,也就是Method Area方法区,现在叫Metaspace。类本身一旦被GC清除,他的所有静态变量也就跟着被释放了。
  3. main thread:main方法就是一个thread线程。java里线程也都是继承自基类,所以自身也是一个大实例。
  4. JNI引用:JNI是支持其他编程语言的本机码和Java字节码互相调用的程序。除了Java进程内部的调用,JVM还需要知道一个实例是否被外部本机代码所调用,JNI引用就列举了当前的外部调用。

所以我们总结一下Mark-Sweep算法(转自标记-清扫算法(简书作者:可文分身)):
在标记阶段,mutator先中断整个程序的运行(Stop-The-World的称呼由此而来)。然后collector从根对象开始进行遍历,对从mutator根对象可以访问到的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象。然后清除阶段,collector对堆内存(heap memory)从头到尾进行线性的遍历,如果发现某个对象没有标记为可达对象-通过读取对象的header信息,则就将其回收。在清除完成以后,mutator在回复程序的运行。 mark_sweep

拷贝到其他代区

如下图1所示,新对象的内存分配先分配在Eden区域,当Eden区域的空间不足于分配新对象时,就会触发年轻代上的垃圾回收(发生在Eden和Survivor内存区域上),我们称之为“Minor Garbage Collection”。 同时,每个对象都有一个“年龄”,这个年龄实际上指的就是该对象经历过的minor gc的次数。如图1所示,当对象刚分配到Eden区域时,对象的年龄为“0”,当minor gc被触发后,所有存活的对象(根据前面的Mark-Sweep算法)会被拷贝到其中一个Survivor区域,同时年龄增长为“1”。并清除整个Eden内存区域中的非可达对象。 youngGeneration 当第二次minor gc被触发时(如图2所示),JVM再次通过Mark算法找出所有在Eden内存区域和Survivor1内存区域存活的对象,并将他们拷贝到新的Survivor2内存区域(这也就是为什么需要两个大小一样的Survivor区域的原因,两个区被交替使用,确保其中一个全空),同时对象的年龄加1. 最后,清除所有在Eden内存区域和Survivor1内存区域的非可达对象。

当对象的年龄足够大(这个年龄可以通过JVM参数进行指定,这里假定是2),当minor gc再次发生时,它会从Survivor内存区域中升级到年老代中,如图3所示。其实,即使对象的年龄不够大,但是Survivor内存区域中没有足够的空间来容纳从Eden升级过来的对象时,也会有部分对象直接升级到Tenured内存区域中。

老年代垃圾回收

当minor gc发生时,又有对象从Survivor区域升级到Tenured区域,但是Tenured区域已经没有空间容纳新的对象了,那么这个时候就会触发年老代上的垃圾回收,我们称之为“Major Garbage Collection”. 而在年老代上选择的垃圾回收算法则取决于JVM上采用的是什么垃圾回收器。通过的垃圾回收器有两种:Parallel Scavenge(PS)Concurrent Mark Sweep(CMS)。他们主要的不同体现在年老代的垃圾回收过程中,年轻代的垃圾回收过程他们都使用前文分析的Mark-Copy算法。顾名思义,Parallel Scavenge垃圾回收器在执行垃圾回收时使用了多线程,以提高垃圾回收的效率。而Concurrent Mark Sweep回收器主要是应用程序挂起”Stop The World”的时间比较短,更接近并发。

Parallel Scavenge垃圾收集器

和Mark-Copy算法不同,PS算法在执行的是Mark-Compact过程。Mark还是之前的mark-sweep过程,标记存活实例,清除不可达实例。不同的是没有一个预留的survivor区来全部拷贝过去。主要是考虑到老年代比较稳定,也比较大,全部拷贝效率上划不来。但问题是空间会碎片化,以后大一点的对象存不进来。所以要来一个compact碎片整理。 oldGeneration

Concurrent Mark Sweep(CMS)垃圾收集器

前面讲了,CMS主要特点是并发,Stop-The-World时间短。从他的名字可以看出,他的主要思想还是源于Mark-Sweep。下面看看他的并发具体是怎么实现的。 PSvsCMS

  1. Initial Mark阶段: 这个阶段还是Stop-The-World的,会挂起程序。但和普通Mark的区别是:它从”根对象”出发,标记到根对象的第一层子节点即停止,马上恢复应用程序的运行。所以程序暂停时间短。
  2. Concurrent Mark阶段: 在这个阶段中,从Initial Mark阶段标记的一代子节点开始标记Tenured区域中所有可达对象。当然,在这个阶段中是不需要暂停程序的。这也是它称为”Concurrent Mark”的原因。
  3. Remark阶段: 但Concurrent Mark和应用程序同时运行的问题是:应用程序一直在分配新对象。所以Concurrent Mark阶段它并不保证所有在Tenured区域的可达对象都被标记了。所以我们需要再次暂停应用程序,再从根节点开始补漏,确保所有的可达对象都被标记。因为老年代比较稳定,一般漏掉的不会太多,所以Remark阶段挂起时间也比较短。
  4. Concurrent Sweep阶段: 最后,恢复应用程序的执行,同时CMS执行sweep,来清除所有非可达对象所占用的内存空间。

所以实际上CMS就是节省了从跟对象一代子对象往下搜索全部可达对象的时间。但CMS有个明显的缺点,就是他没有碎片整理的过程。对空间的利用不好,容易引发out of memory。

Garbage First(G1)垃圾收集器

针对CMS这个没有碎片整理的问题,同时又保留CMS垃圾收集器低暂停时间的优点,JAVA7发布了一个新的垃圾收集器 - Garbage First(G1)垃圾收集器

G1垃圾收集器和CMS垃圾收集器有几点不同。首先,最大的不同是内存的组织方式变了。Eden,Survivor和Tenured等内存区域不再是连续的了,而是变成了下图中一个个大小一样的Region - 每个region从1M到32M不等。 g1 一个region有可能属于Eden,Survivor或者Tenured内存区域。图中的E表示该region属于Eden内存区域,S表示属于Survivor内存区域,T表示属于Tenured内存区域。图中空白的表示未使用的内存空间。G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。这种内存区域主要用于存储大对象-即大小超过一个region大小的50%的对象。区隔变小的好处这里就体现出来了,对这种Humongous区就能特殊情况特殊照顾了,省了很多扫描的时间。

在G1垃圾收集器中,年轻代的垃圾回收过程跟PS垃圾收集器和CMS垃圾收集器差不多,新对象的分配还是在Eden region中,当所有Eden region的大小超过某个值时,触发minor gc,回收Eden region和Survivor region上的非可达对象,同时升级存活的可达对象到对应的Survivor region和Tenured region上。对象从Survivor region升级到Tenured region依然是取决于对象的年龄。 g1Young 对于年老代上的垃圾收集,G1垃圾收集器也分为4个阶段,基本跟CMS垃圾收集器一样,但主要的改进有一下几项:

  1. 碎片整理:多了Clean up/Copy阶段: CMS最大的缺陷就是没有碎片整理。G1明显改进了,没有CMS中对应的Sweep阶段。相反它有一个Clean up/Copy阶段。现在G1里,老年代也像年轻代一样标记清扫之后要重新拷贝到新的region里去了。这样划分小区隔region的好处就是,不同代区的转化分配更自由合理了。
  2. 更高的并发性: 同CMS垃圾收集器的Initial Mark阶段一样,G1也需要暂停应用程序的执行,也只标记根对象的第一层孩子节点中的可达对象。但是G1的垃圾收集器的Initial Mark阶段和Clean up/Copy阶段是跟minor gc一同发生的,在G1触发年轻代minor gc的时候聪明地一并将年老代上的Initial Mark给做了。
  3. 扫描,标记的同时回收:在Concurrent Mark阶段中,发现哪些Tenured region中对象的存活率很小或者基本没有对象存活,那么G1就会在这个阶段将其回收掉,而不用等到后面的clean up阶段。这也是Garbage First名字的由来。这样小region设计的好处也出来了,整个系统显得更加灵活。包括上文提到的Humongous内存区域,大对象单独占一个区,可以单独特殊处理,效率更高。
  4. 新Remark算法SATB:因为Initial Mark阶段的程序挂起现在在minor gc的时候顺便做掉了,G1在处理老年代的时候唯一还需要挂起的就是Remark补漏阶段。所以G1采用了一种叫SATB(snapshot-at-the-begining)的算法能够在Remark阶段更快的标记可达对象。

g1Old

所以综合来讲,G1加上了CMS没有的碎片整理功能,同时程序挂起时间更短了,并发性更高了,而且存活对象的标记效率也更高了。目前G1正在全面替换掉CMS。

小结

至此,我对Java垃圾回收器GC的原理做了一个简单的了解。我深深感受到系统过程设计的复杂度。所以当我们在愉快地编程的时候,JVM默默地为我们做了这么多的事。实际本文只是做一个草草的鸟览,文中所描述过程的真正复杂性还完全没有被揭示出来。事实上每一个对复杂性的掩盖,都凝聚着无数设计师深沉的思考。而这些深沉的思考才是我们人类进步最坚定有力的阶梯,至少比某些主义来的实用地多。我觉得它们很美。